/**
 * Copyright 2009 Avlesh Singh
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.avlesh.web.filter.responseheaderfilter;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.collections.CollectionUtils;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.w3c.dom.Node;
import org.w3c.dom.NamedNodeMap;
import java.util.*;
import java.util.regex.Pattern;
import java.io.File;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.DocumentBuilder;

/**
 * <p style="margin:5px">
 * Reads the mappings present in the filter <code>configFile</code> and converts them into a <code>List</code> of {@link Mapping}.
 * <br/>
 * Underneath is an example for a sample mapping file:
 * </p>
 *<pre>
 *&lt;response-header-mapper&gt;
 * &lt;mapping url="/index.html"&gt;
 *  &lt;default&gt;
 *   &lt;response-headers&gt;
 *    &lt;header key="Cache-Control" value="private"/&gt;
 *   &lt;/response-headers&gt;
 *  &lt;/default&gt;
 *
 *  &lt;conditional queryParamName="father" queryParamValue="(mother|sister)"&gt;
 *   &lt;response-headers&gt;
 *    &lt;header key="Cache-Control" value="no-cache"/&gt;
 *    &lt;header key="X-ServerName" value="Foo-Kumar-Singh"/&gt;
 *   &lt;/response-headers&gt;
 *  &lt;/conditional&gt;
 *
 *  &lt;conditional queryParamName="father" queryParamValue="brother"&gt;
 *   &lt;response-headers&gt;
 *    &lt;header key="X-ServerName" value="Moo-Kumar-Singh"/&gt;
 *   &lt;/response-headers&gt;
 *  &lt;/conditional&gt;
 * &lt;/mapping&gt;
 *&lt;/response-header-mapper&gt;
 * </pre>
 *
 * For parsing rules, see {@link #processConfig()}
 *
 * @see Mapping
 * @see Mapping.Condition
 * @see ResponseHeaderFilter
 */
public class ConfigProcessor {
  //the filter config file
  private File configFile;

  //list of all parsed mappings
  private List<Mapping> mappings = new ArrayList<Mapping>();

  protected static Log logger = LogFactory.getLog(ConfigProcessor.class);

  ConfigProcessor(File configFile){
    this.configFile = configFile;
  }

  /**
   * Based on the <code>rules</code> generated by the {@link #processConfig()} method, this method
   * generates a <code>Map</code> of all the unique URL's and their corresponding {@link Mapping}.
   *
   * @return Map&lt;Pattern, Rule&gt; (<code>Map</code> of all unique url patterns and their corresponding <code>Rule</code>)
   */
  public Map<Pattern, Mapping> getRuleMap(){
    this.processConfig();
    Map<Pattern, Mapping> ruleMap = new LinkedHashMap<Pattern, Mapping>();
    for(Mapping mapping : mappings){
      //mappings for exactly the same url pattern will replace the earlier one
      ruleMap.put(mapping.getUrl(), mapping);
    }
    return ruleMap;
  }

  /**
   * <code>configFile</code> processor: Processes the file according to these rules:
   * <ol>
   * <li>The file <b>should</b> have <code>response-header-mapper</code> as the root node.</li>
   * <li>Each node (in the xml) with the name <code>mapping</code> is identified as mapping rule and
   * gets converted into a {@link Mapping}.<br/><code>url</code> is a mandatory attribute in the <code>mapping</code> node;
   * mappings without a <code>url</code> are rejected.
   * </li>
   * <li>Subsequent mappings for the same <code>url</code> will <b>OVERRIDE</b> the previous {@link Mapping}.
   * <i>Last <code>Rule</code> wins</i>.
   * </li>
   * <li>Each mapping can have only <b>one <code>default</code></b> <code>response-header</code> list.
   * In cases of multiple such declarations, the <i>Last &lt;default&gt; declaration wins</i>.
   * </li>
   * <li>Each mapping can have any number of <code>conditional</code> mappings.
   * All these rules are treated as mutually exclusive.
   * </li>
   * <li>Both, <code>default</code> and <code>conditional</code> nodes <b>should</b> have a &lt;response-headers&gt;
   * node. In case of multiple such nodes, <i>Last &lt;response-headers&gt; declaration wins</i>.
   * </li>
   * <li>Each &lt;response-headers&gt; node may contain one or more &lt;header&gt; nodes. Each such node has to have two mandatory
   * attributes, <code>key</code> and <code>value</code>.</li>
   * <li>Both, <code>queryParamName</code> and <code>queryParamValue</code>, are required attributes in a <code>conditional</code> tag.
   * They <b>can't</b> be left blank or undeclared.
   * </li>
   * <li>Values inside the <code>queryParamValue</code> attribute are parsed as a <code>Pattern</code>.</li>
   * </ol>
   *
   * For rules on the implementation of the filter, go here -
   * {@link ResponseHeaderFilter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain)}
   * @see #getCondition(org.w3c.dom.Node)
   * @see #getResponseHeader(org.w3c.dom.Node)
   */
  protected void processConfig(){
    if(logger.isDebugEnabled()){
      logger.debug("Processing the response header manager filter's config file: " + configFile.getName());
    }
    DocumentBuilderFactory docBuilderFactory = DocumentBuilderFactory.newInstance();
    Document doc;
    try{
      DocumentBuilder docBuilder = docBuilderFactory.newDocumentBuilder();
      doc = docBuilder.parse(configFile);
    }catch(Exception ex){
      //Parse exception in the xml, not expected
      throw new RuntimeException("Check " + configFile.getName() + " for errors. The file might have some unbalanced nodes");
    }

    doc.getDocumentElement().normalize();
    String rootElementName = doc.getDocumentElement().getNodeName();
    //expecting <response-header-mapper> as the root node
    if(Constants.RESPONSE_HEADER_MAPPER.equalsIgnoreCase(rootElementName)){
      String processorClassName = doc.getDocumentElement().getAttribute("processorClass");
      MappingProcessor processorClassInstance = getProcessorClass(processorClassName);
      //the user has not configured any ProcessorClass, lets use our default one
      if(processorClassInstance == null){
        processorClassInstance = new DefaultMappingProcessor();
      }

      NodeList allMappingNodes = doc.getDocumentElement().getElementsByTagName(Constants.MAPPING);
      if(allMappingNodes != null && allMappingNodes.getLength() > 0){
        for(int i=0; i< allMappingNodes.getLength(); i++){
          MappingProcessor mappingProcessorForThisRule = null;
          String urlStr = null;
          List<Mapping.ResponseHeader> defaultResponseHeaders = new ArrayList<Mapping.ResponseHeader>();
          Map<Mapping.Condition, List<Mapping.ResponseHeader>> conditionalResponseHeaders =
              new LinkedHashMap<Mapping.Condition, List<Mapping.ResponseHeader>>();

          Node mappingNode = allMappingNodes.item(i);
          NamedNodeMap mappingNodeAttributes = mappingNode.getAttributes();
          if(mappingNodeAttributes != null){
            Node urlAttributeNode = mappingNodeAttributes.getNamedItem("url");
            if(urlAttributeNode != null){
              urlStr = urlAttributeNode.getNodeValue();
            }

            Node processorClassNode = mappingNodeAttributes.getNamedItem("processorClass");
            if(processorClassNode != null){
              mappingProcessorForThisRule = getProcessorClass(processorClassNode.getNodeValue());
            }
          }

          //no url specified in this mappingNode, skip and continue ...
          if(StringUtils.isEmpty(urlStr)){
            logger.warn("Encountered a mapping without a mandatory url attribute. Skipping ...");
            continue;
          }

          if(mappingProcessorForThisRule == null){
            mappingProcessorForThisRule = processorClassInstance;
          }

          Pattern url = Pattern.compile(urlStr); 
          NodeList mappingChildNodes = mappingNode.getChildNodes();
          for(int j=0; j<mappingChildNodes.getLength(); j++){
            Node node = mappingChildNodes.item(j);
            String nodeName = node.getNodeName();

            //if the node is of type <default>, just parse the list of response headers
            if(Constants.DEFAULT.equalsIgnoreCase(nodeName)){
              defaultResponseHeaders = getResponseHeader(node);
            }
            //if the node is <conditional>, parse the <code>Condition</code> and then the corresponding
            //list of response headers
            else if(Constants.CONDITIONAL.equalsIgnoreCase(nodeName)){
              Mapping.Condition condition = getCondition(node);
              //add the list of headers against this condition, only when both of them are "valid" (read not null)
              if(condition != null){
                List<Mapping.ResponseHeader> responseHeaders = getResponseHeader(node);
                if(CollectionUtils.isNotEmpty(responseHeaders)){
                  conditionalResponseHeaders.put(condition, responseHeaders);
                }
              }else{
                logger.warn("Both queryParamName and queryParamValue have to be set for the <" + Constants.CONDITIONAL + "> tag");
                continue;
              }
            }

            //if this mappingNode has "valid" allMappingNodes, add it as a rule in the mapper
            if(CollectionUtils.isNotEmpty(defaultResponseHeaders) || !conditionalResponseHeaders.isEmpty()){
              Mapping newMapping = new Mapping();
              newMapping.setProcessorClass(mappingProcessorForThisRule);
              newMapping.setUrl(url);
              newMapping.setDefaultResponseHeaders(defaultResponseHeaders);
              newMapping.setConditionalResponseHeaders(conditionalResponseHeaders);
              this.mappings.add(newMapping);
            }
          }
        }
      }else{
        //the filter is useless, no valid allMappingNodes found in the config file
        if(logger.isInfoEnabled()){
          logger.info("No valid mappings found. Huh!");
        }
      }
    }else{
      //absense of <response-header-mapper> node not expected, throwing a runtime exception
      throw new RuntimeException("Root node <" + Constants.RESPONSE_HEADER_MAPPER + "> missing from the file");
    }
  }

  private MappingProcessor getProcessorClass(String processorClass) {
    MappingProcessor processorClassInstance = null;
    if(StringUtils.isNotEmpty(processorClass)){
        try {
        processorClassInstance = (MappingProcessor)(Class.forName(processorClass).newInstance());
      } catch (ClassNotFoundException e) {
        throw new RuntimeException("Processor class " + processorClass + " not found");
      } catch (ClassCastException e){
        throw new RuntimeException("Processor class " + processorClass + " should implement the " +
            MappingProcessor.class.getCanonicalName() + " interface");
      } catch (Exception e) {
        throw new RuntimeException("Unable to instantiate the processor class: " + processorClass);
      }
    }
    return processorClassInstance;
  }

  /**
   * Parser for a <code>&lt;conditional&gt;</code> node; converts the node into a
   * {@link Mapping.Condition}
   * <br/>
   * Nodes with empty or undeclared <code>queryParamName</code> and/or <code>queryParamValue</code> are not parsed
   * and return a null.
   * <br/>
   * <code>queryParamValue</code> is parsed as a {@link Pattern}
   *
   * @param node (&lt;conditional&gt; {@link Node} in the <code>configFile</code>)
   * @return {@link Mapping.Condition} (parsed &lt;conditional&gt; tag)
   */
  private Mapping.Condition getCondition(Node node) {
    Mapping.Condition condition = null;
    NamedNodeMap attributeMap = node.getAttributes();
    Node queryParamNameNode = attributeMap.getNamedItem("queryParamName");
    Node queryParamValueNode = attributeMap.getNamedItem("queryParamValue");
    //both attributes are mandatory
    if(queryParamNameNode != null && queryParamValueNode != null){
      String queryParamName = queryParamNameNode.getNodeValue();
      String queryParamValue = queryParamValueNode.getNodeValue();
      //empty values for either of the attributes is not acceptable
      if(StringUtils.isNotEmpty(queryParamName) && StringUtils.isNotEmpty(queryParamValue)){
        condition = new Mapping.Condition();
        condition.setQueryParamName(queryParamName);
        condition.setQueryParamValue(Pattern.compile(queryParamValue));
      }
    }
    return condition;
  }

  /**
   * Default response header parser for the filter.
   * <br/>
   * Parses all the &lt;header&gt; nodes inside a &lt;response-headers&gt;
   * <br/>
   * Returns a <code>null</code>, if there are no "valid" &lt;header&gt; nodes to be parsed. A &lt;header&gt; node
   * is considered invalid if any of the attributes (<code>key</code> or <code>value</code>) is missing or undeclared.
   *
   * @param node (&lt;response-headers&gt; {@link Node} in the <code>configFile</code>)
   * @return {@link Mapping.ResponseHeader} (parsed &lt;header&gt; tags in the <code>node</code>)
   */
  private List<Mapping.ResponseHeader> getResponseHeader(Node node){
    List<Mapping.ResponseHeader> responseHeaders = null;
    NodeList nodeList = node.getChildNodes();
    if (nodeList != null && nodeList.getLength() > 0) {
      for (int k = 0; k < nodeList.getLength(); k++) {
        Node responseHeaderNode = nodeList.item(k);
        String responseHeaderNodeName = responseHeaderNode.getNodeName();
        //parse the <response-headers> tags for the incoming node
        //if there are multiple such headers, last one wins
        if (Constants.RESPONSE_HEADERS.equalsIgnoreCase(responseHeaderNodeName)) {
          responseHeaders = new ArrayList<Mapping.ResponseHeader>();
          NodeList responseHeaderNodes = responseHeaderNode.getChildNodes();
          if (responseHeaderNodes != null && responseHeaderNodes.getLength() > 0) {
            for (int l = 0; l < responseHeaderNodes.getLength(); l++) {
              Node headerNode = responseHeaderNodes.item(l);
              if (Constants.HEADER.equalsIgnoreCase(headerNode.getNodeName())) {
                NamedNodeMap attributeMap = headerNode.getAttributes();
                Node keyNode = attributeMap.getNamedItem("key");
                Node valueNode = attributeMap.getNamedItem("value");
                //accepting a <header> node, only if the "key" and "value" attributes are present
                if (keyNode != null && valueNode != null) {
                  String key = keyNode.getNodeValue();
                  String value = valueNode.getNodeValue();
                  //empty values for either the "key" or the "value" are not acceptable
                  if (StringUtils.isNotEmpty(key) && StringUtils.isNotEmpty(value)) {
                    Mapping.ResponseHeader responseHeader = new Mapping.ResponseHeader();
                    responseHeader.setResponseHeaderKey(key);
                    responseHeader.setResponseHeaderValue(value);
                    responseHeaders.add(responseHeader);
                  }
                }

                //something wrong with the header declaration, issuing a warning
                if (responseHeaders.isEmpty()) {
                  logger.warn("Skipping a <" + Constants.HEADER + "> node. " +
                      "key and value are required attributes for these nodes");
                }
              }
            }
          }
        }
      }
    }
    return responseHeaders;
  }
}
